Краткое описание: в стартапе по продаже продуктов питания имеется мобильное приложение. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Выясните, какой шрифт лучше.
Цель:
Описание данных:
EventName — название события;DeviceIDHash — уникальный идентификатор пользователя;EventTimestamp — время события;ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.План работы:
# Импорт библиотек
import pandas as pd
import numpy as np
import math as mth
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats as st
import plotly.express as px
from plotly import graph_objects as go
df = pd.read_csv('/datasets/logs_exp.csv', sep='\s+') # Серверный путь
# Снятие ограничений на отражение максимального коливества столбцов и символов
pd.set_option('display.max_columns', None)
pd.set_option('display.max_colwidth', None)
# Вывод информации о датафрейме
df.info()
# Получение первых 5 строк таблицы df
df.head()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
В датафрейме 244126 строк. Пропусков нет, но нужно привести значения к змеиному регистру и привести данные к нужному типу.
df = df.rename(columns = {'EventName':'event_name','DeviceIDHash':'device_id_hash','EventTimestamp':'event_timestamp','ExpId':'exp_id'})
df['event_timestamp'] = pd.to_datetime(df['event_timestamp'], unit='s')
df['date_time'] = pd.to_datetime(df['event_timestamp']).dt.round('1S')
df['date'] = pd.to_datetime(df['date_time']).dt.floor('d')
df.info()
df.head()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_name 244126 non-null object 1 device_id_hash 244126 non-null int64 2 event_timestamp 244126 non-null datetime64[ns] 3 exp_id 244126 non-null int64 4 date_time 244126 non-null datetime64[ns] 5 date 244126 non-null datetime64[ns] dtypes: datetime64[ns](3), int64(2), object(1) memory usage: 11.2+ MB
| event_name | device_id_hash | event_timestamp | exp_id | date_time | date | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 04:43:36 | 246 | 2019-07-25 04:43:36 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 11:11:42 | 246 | 2019-07-25 11:11:42 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 11:48:42 | 248 | 2019-07-25 11:48:42 | 2019-07-25 |
group_246 = list(df[df['exp_id'] == 246]['device_id_hash'].unique())
group_247 = list(df[df['exp_id'] == 247]['device_id_hash'].unique())
group_248 = list(df[df['exp_id'] == 248]['device_id_hash'].unique())
duplicatedid = list(set(group_246)&set(group_247))+list(set(group_247)&set(group_248))+list(set(group_246)&set(group_248))
duplicatedid
[]
Пересекающиеся пользователи отсутствуют.
df.duplicated().sum()
413
df = df.drop_duplicates().reset_index(drop=True)
Убрали дубликаты
df['event_name'].unique()
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear',
'OffersScreenAppear', 'Tutorial'], dtype=object)
events_count = df.groupby('event_name')['device_id_hash'].count().sort_values(ascending = False)
events_count
event_name MainScreenAppear 119101 OffersScreenAppear 46808 CartScreenAppear 42668 PaymentScreenSuccessful 34118 Tutorial 1018 Name: device_id_hash, dtype: int64
events_count.sum()
243713
Всего в логе 244 126 событий.
Описание данных:
MainScreenAppear — показ главного экрана;OffersScreenAppear — страница с продуктами;CartScreenAppear — экран с корзиной;PaymentScreenSuccessful — экран с завершением оплаты;Tutorial - экран с обучением.users_count = df.groupby('exp_id')['device_id_hash'].nunique()
users_count
exp_id 246 2489 247 2520 248 2542 Name: device_id_hash, dtype: int64
Группы пользователей распределены равномерно.
users_count.sum()
7551
Всего 7551 пользователь.
events_per_user = df.groupby('device_id_hash')['exp_id'].count().reset_index()
events_per_user['exp_id'].plot(kind = 'hist', bins = 200,figsize = (10,5))
plt.title('Количество событий в расчете на пользователя')
plt.xlabel('Количество событий')
plt.ylabel('Пользователи');
events_per_user.describe()
| device_id_hash | exp_id | |
|---|---|---|
| count | 7.551000e+03 | 7551.000000 |
| mean | 4.677319e+18 | 32.275593 |
| std | 2.655343e+18 | 65.154219 |
| min | 6.888747e+15 | 1.000000 |
| 25% | 2.397700e+18 | 9.000000 |
| 50% | 4.688022e+18 | 20.000000 |
| 75% | 7.007353e+18 | 37.000000 |
| max | 9.222603e+18 | 2307.000000 |
В данных присутствуют аномальные значения, поэтому их нужно будет отфильтровать.
np.percentile(events_per_user['exp_id'], 95)
89.0
np.percentile(events_per_user['exp_id'], 99.5)
270.25
Предлагаю принять значения заказов на пользователя, которые превышают 270, за аномальные значения, так как такие пользователи составляют не более 1-ого процента.
len(df)
243713
device_id_hash_anomal = list(events_per_user.query('exp_id >= 270')['device_id_hash'])
df = df.query('device_id_hash not in @device_id_hash_anomal')
df.shape
(217812, 6)
df['date_time'].min()
Timestamp('2019-07-25 04:43:36')
df['date_time'].max()
Timestamp('2019-08-07 21:15:17')
df['date_time'].hist(bins=250, figsize=(15,5))
plt.xticks(rotation = 45)
plt.ylabel('Количество событий')
plt.xlabel('Дата')
plt.title('Распределение событий по времени')
plt.show()
По графику видно, что данные для анализа начали активно сообираться с 01.08, а до этого релевантные данные отсутствуют (возможно, техническая ошибка или неправильное проведение теста), поэтому для проведения исследования нам необходимо анализировать только те данные, кооторые собирались в период с 01.08 по 07.08
df=df.query('date >= "2019-08-01"')
df.shape
(215051, 6)
Проверим изменилось ли количество пользователей по новым данным.
users_count_new = df.groupby('exp_id')['device_id_hash'].nunique()
users_count_new
exp_id 246 2472 247 2505 248 2518 Name: device_id_hash, dtype: int64
lost_users = abs(users_count_new.sum() - users_count.sum())
F"Количество отфильтрованных пользователей - {lost_users}, что составляет всего {round(lost_users/users_count.sum()*100,1)} % от общего количества пользователей"
'Количество отфильтрованных пользователей - 56, что составляет всего 0.7 % от общего количества пользователей'
events_count_new = df.groupby('event_name')['device_id_hash'].count().sort_values(ascending = False)
events_count_new.sum()
215051
events_count.sum()
243713
lost_users = abs(users_count_new.sum() - users_count.sum())
F"Количество отфильтрованных пользователей - {lost_users}, что составляет {round(lost_users/users_count.sum()*100,1)} % от общего количества пользователей"
'Количество отфильтрованных пользователей - 56, что составляет 0.7 % от общего количества пользователей'
lost_events = abs(events_count_new.sum() - events_count.sum())
F"Количество отфильтрованных событий - {lost_events}, что составляет {round(lost_events/events_count.sum()*100,1)} % от общего количества событий"
'Количество отфильтрованных событий - 28662, что составляет 11.8 % от общего количества событий'
После удаления дубликатов, выбросов и неактуального периода распределение по пользователям осталось равномерным и из выборки удалено 56 пользоветелй (0.7%) и 28 662 события (11.8%).
fig = px.bar(events_count, labels={'event_name':'Страница', 'value': 'Трафик'}, text='value')
fig.update_layout(title_text='Количество событий', title_x=0.5)
fig.update_layout(showlegend=False)
fig.show()
events_count.plot(kind = 'pie',autopct='%1.1f%%')
plt.title('Доля каждого события')
plt.ylabel(' ')
plt.show()
Видно, что примерно половина событий приходится на главный экран (119 тысяч и 48.8% от общего количества событий), в то время как на экран с продуктами, который занимает 2ое место (46 тысяч событий и 19.2% от общего количества событий), переходят более чем в два раза меньше пользователей.
steps_users_count = df.groupby('event_name').agg({'device_id_hash':'nunique'}).sort_values(by = 'device_id_hash', ascending = False).reset_index()
steps_users_count['ratio'] = round(steps_users_count['device_id_hash']/df['device_id_hash'].nunique(), 2)
steps_users_count
| event_name | device_id_hash | ratio | |
|---|---|---|---|
| 0 | MainScreenAppear | 7380 | 0.98 |
| 1 | OffersScreenAppear | 4554 | 0.61 |
| 2 | CartScreenAppear | 3695 | 0.49 |
| 3 | PaymentScreenSuccessful | 3500 | 0.47 |
| 4 | Tutorial | 834 | 0.11 |
for i in range(0,5):
if i == 0:
steps_users_count.loc[steps_users_count.index[i],'cumul_ratio'] = round(steps_users_count.loc[steps_users_count.index[i],'device_id_hash']/df['device_id_hash'].nunique(), 2)
elif steps_users_count.loc[steps_users_count.index[i],'event_name'] == 'Tutorial':
steps_users_count.loc[steps_users_count.index[i],'cumul_ratio'] = 'Unknown'
else:
steps_users_count.loc[steps_users_count.index[i],'cumul_ratio'] = steps_users_count.loc[steps_users_count.index[i],'device_id_hash']/steps_users_count.loc[steps_users_count.index[i-1],'device_id_hash']
steps_users_count
| event_name | device_id_hash | ratio | cumul_ratio | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 7380 | 0.98 | 0.98 |
| 1 | OffersScreenAppear | 4554 | 0.61 | 0.617073 |
| 2 | CartScreenAppear | 3695 | 0.49 | 0.811375 |
| 3 | PaymentScreenSuccessful | 3500 | 0.47 | 0.947226 |
| 4 | Tutorial | 834 | 0.11 | Unknown |
fig = go.Figure(go.Funnel(
y = steps_users_count[0:4]['event_name'],
x = steps_users_count[0:4]['device_id_hash'],
textposition = "inside",
textinfo = "value+percent initial"))
fig.show()
Пользователи начинают с главного экрана, затем попадают на экран с товарами, далее на экран с корзиной, а потом на экран с успешной оплатой. Экран с обучением, судя по всему, не является обязательным, поэтому на него попадает меньше всего пользователей.
round(steps_users_count.loc[steps_users_count.index[3]]['device_id_hash']/steps_users_count.loc[steps_users_count.index[0]]['device_id_hash'],2)
0.47
fig = px.bar(users_count_new, labels={'exp_id':'Номер эксперимента', 'value': 'Количество'}, text='value')
fig.update_traces(textposition='outside')
fig.update_layout(title_text='Количество пользователей', title_x=0.5)
fig.update_layout(showlegend=False)
fig.show()
Группы распределены равномерно. Группа 246 и 247 - это контрольные группы, а группа 248 - экспериментальная. Проверим насколько правильно распределены показатели между контрольными группами.
Нулевая гипотеза - между группами 246 и 247 нету статистической разницы. Альтернативная гипотеза - между группами 246 и 247 есть статистическая разница.
# Создание таблицы с количеством пользователей по группам
users_group_count = (df.groupby(['exp_id', 'event_name']).agg({'device_id_hash':'nunique'})
.pivot_table(index='event_name', columns='exp_id', values='device_id_hash')
.reset_index()
.sort_values(246, ascending=False))
fig = go.Figure()
fig.add_trace(go.Funnel(
name = '246',
y = users_group_count['event_name'],
x = users_group_count[246],
textinfo = "value+percent initial"))
fig.add_trace(go.Funnel(
name = '247',
orientation = "h",
y = users_group_count['event_name'],
x = users_group_count[247],
textinfo = "value+percent initial"))
fig.add_trace(go.Funnel(
name = '248',
orientation = "h",
y = users_group_count['event_name'],
x = users_group_count[248],
textposition = "inside",
textinfo = "value+percent initial"))
fig.show()
for i in [246,247,248]:
if i == 246:
group_246 = list(users_group_count[i])
group_246.insert(0,df.query('exp_id == @i')['device_id_hash'].nunique())
elif i == 247:
group_247 = list(users_group_count[i])
group_247.insert(0,df.query('exp_id == @i')['device_id_hash'].nunique())
elif i == 248:
group_248 = list(users_group_count[i])
group_248.insert(0,df.query('exp_id == @i')['device_id_hash'].nunique())
group_246
[2472, 2438, 1530, 1254, 1188, 274]
# Функция для расчёта статистически значимой разницы между долями
def find_statistical_significance(first_list, second_list):
# Названия событий для цикла
event_list = ['MainScreenAppear', 'OffersScreenAppear', 'CartScreenAppear', 'PaymentScreenSuccessful', 'Tutorial']
# Основной цикл функции, который вычисляет p-value и проводит тест для каждого экрана
for x in range(0, len(first_list)-1):
y1 = first_list[0]
y2 = second_list[0]
x1 = first_list[x+1]
x2 = second_list[x+1]
alpha = 0.05/20 #применяем поправку бонферони с учетом трех групп и двадцати проверок между ними.
successes = np.array([x1, x2])
trials = np.array([y1, y2])
p1 = successes[0]/trials[0] # пропорция успехов в первой группе
p2 = successes[1]/trials[1] # пропорция успехов во второй группе
# Расчёт совокупной пропорции в комбинированном датасете
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
print (successes, trials)
difference = p1 - p2 # разница пропорций в датасетах
# Расчёт статистики в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1])) # статистикa в ст.отклонениях стандартного нормального распределения
distr = st.norm(0, 1) # задаем стандартное нормальное распределение
p_value = 1 - distr.cdf(z_value)
print('Событие:', event_list[x])
print('p-значение: ', round(p_value, 2))
# проверка гипотез по p-value и уровню статистической значимости
if (p_value < alpha):
print("Отвергаем нулевую гипотезу")
else:
print("Не получилось отвергнуть нулевую гипотезу")
find_statistical_significance(group_246,group_247)
[2438 2468] [2472 2505] Событие: MainScreenAppear p-значение: 0.38 Не получилось отвергнуть нулевую гипотезу [1530 1512] [2472 2505] Событие: OffersScreenAppear p-значение: 0.13 Не получилось отвергнуть нулевую гипотезу [1254 1230] [2472 2505] Событие: CartScreenAppear p-значение: 0.13 Не получилось отвергнуть нулевую гипотезу [1188 1150] [2472 2505] Событие: PaymentScreenSuccessful p-значение: 0.06 Не получилось отвергнуть нулевую гипотезу [274 283] [2472 2505] Событие: Tutorial p-значение: 0.59 Не получилось отвергнуть нулевую гипотезу
Ни на одном из шагов в группах нету статистической разницы, поэтому разделение на контрольные группы работает корректно.
find_statistical_significance(group_246,group_248)
[2438 2474] [2472 2518] Событие: MainScreenAppear p-значение: 0.14 Не получилось отвергнуть нулевую гипотезу [1530 1512] [2472 2518] Событие: OffersScreenAppear p-значение: 0.09 Не получилось отвергнуть нулевую гипотезу [1254 1211] [2472 2518] Событие: CartScreenAppear p-значение: 0.03 Не получилось отвергнуть нулевую гипотезу [1188 1162] [2472 2518] Событие: PaymentScreenSuccessful p-значение: 0.09 Не получилось отвергнуть нулевую гипотезу [274 277] [2472 2518] Событие: Tutorial p-значение: 0.46 Не получилось отвергнуть нулевую гипотезу
find_statistical_significance(group_247,group_248)
[2468 2474] [2505 2518] Событие: MainScreenAppear p-значение: 0.22 Не получилось отвергнуть нулевую гипотезу [1512 1512] [2505 2518] Событие: OffersScreenAppear p-значение: 0.41 Не получилось отвергнуть нулевую гипотезу [1230 1211] [2505 2518] Событие: CartScreenAppear p-значение: 0.24 Не получилось отвергнуть нулевую гипотезу [1150 1162] [2505 2518] Событие: PaymentScreenSuccessful p-значение: 0.57 Не получилось отвергнуть нулевую гипотезу [283 277] [2505 2518] Событие: Tutorial p-значение: 0.37 Не получилось отвергнуть нулевую гипотезу
control_group = [x+y for x, y in zip(group_246, group_247)]
find_statistical_significance(control_group,group_248)
[4906 2474] [4977 2518] Событие: MainScreenAppear p-значение: 0.14 Не получилось отвергнуть нулевую гипотезу [3042 1512] [4977 2518] Событие: OffersScreenAppear p-значение: 0.18 Не получилось отвергнуть нулевую гипотезу [2484 1211] [4977 2518] Событие: CartScreenAppear p-значение: 0.07 Не получилось отвергнуть нулевую гипотезу [2338 1162] [4977 2518] Событие: PaymentScreenSuccessful p-значение: 0.25 Не получилось отвергнуть нулевую гипотезу [557 277] [4977 2518] Событие: Tutorial p-значение: 0.4 Не получилось отвергнуть нулевую гипотезу
Сравнивая группы А/А c группой B по отдельности и в совокупности мы не можем увидеть статистическое различие, поэтому новые шрифты никак не повлияли на конверсию пользователей (ни в худшую, ни в лучшую сторону).